[Previous] [Next]

Events

Until Visual Basic 4, the term class events could refer only to the internal Class_Initialize and Class_Terminate events that the Visual Basic runtime fires when an object is created and destroyed. In versions 5 and 6, however, classes are able to raise events to the outside, exactly as controls and forms can. This ability dramatically increases the potential of class modules, making it possible to integrate them more tightly in applications while continuing to consider them separate and reusable modules.

Events and Code Reusability

Before showing how a class module can expose events to the outside and how the client code can trap them, let me explain why events are so important for code reuse. The ability to create a piece of code that can be recycled as is in other projects is too tantalizing to leave any programmer indifferent to the possibility. To illustrate the concept, I'll describe an imaginary class module whose primary job is copying a series of files and optionally informing the caller about the progress of the operation (so that the caller code can display a progress bar or a message on the status bar for the user). Without events, we have two possible ways to implement this code, both of which are clearly unsatisfactory:

Finally events made their appearance and offered the best solution so far to the dilemma:

Syntax for Events

Implementing events in a class module and using them in a client module is a straightforward process, which consists of just a few logical, easy steps. Figure 7-1 shows how the implementation works. I'm using as an example the hypothetical CFileOp class, which copies multiple files, as I described previously.

Click to view at full size.

Figure 7-1. Implementing events in a class module.

Declaring an event

To expose an event to its clients, a class must include an Event statement in its declaration section. This statement serves to inform the outside world about the event's name as well as its arguments. For example, the CFileOp class might expose this event:

Event FileCopyComplete(File As String, DestPath As String)

There's nothing special about the syntax of arguments, and you can in fact declare arguments of any type supported by Visual Basic, including objects, collections, and Enum values.

Raising an event

When the time comes for the class to raise an event, it executes a RaiseEvent statement, which specifies both the event name and its actual arguments. Again, this isn't conceptually different from calling a procedure, and you'll also find that Microsoft IntelliSense can give you a hand in both selecting the event name and the values for its arguments. In the CFileOp class, you could therefore write something like this:

RaiseEvent FileCopyComplete "c:\bootlog.txt", "c:\backup"

This is all you need to do in the class module. Now let's see what the client code does.

Declaring the object in the client module

If you're writing code in a form or a class module and you want to receive events from an object, you must declare a reference to that object in the declaration section of the module, using the WithEvents keyword:

' You can use Public, Private, or Dim, as needed.
Dim WithEvents FOP As CFileOp

You should be aware of a few facts about the WithEvents clause:

Trapping the event

At this point, Visual Basic has all the information it needs to respond to events raised by the object. In fact, if you look at the list portion of the leftmost combo box at the top of the code editor window, you'll see that the variable you have declared using WithEvents appears in the list, together with all the controls already on the form. Select it and move to the rightmost combo box control to choose the event that interests you. (In this example, there's only one such event.) As it happens for events coming from controls, Visual Basic automatically creates the procedure template for you, and all you have to do is fill it with some meaningful code:

Private Sub Fop_FileCopyComplete(File As String, DestPath As String)
    MsgBox "File " & File & " has been copied to " & DestPath
End Sub

A First, Complete Sample Application

Now that all syntax details have been explained, it's time to complete the CFileOp class, which is able to copy one or multiple files and provide feedback to the caller. As you'll see shortly, this initial sample program provides us with the playground for more complex and interesting programming techniques based on events.

The CFileOp class module

Let's create a class module and name it CFileOp. This class exposes a few properties that allow the client to decide which files should be copied (FileSpec, Path, and Attributes properties) and a method that starts the actual copy process. As I indicated, the class also exposes a FileCopyComplete event:

' The CFileOp class module
Event FileCopyComplete(File As String, DestPath As String)
Private m_FileSpec As String
Private m_Filenames As Collection
Private m_Attributes As VbFileAttribute

Property Get FileSpec() As String
    FileSpec = m_FileSpec
End Property
Property Let FileSpec(ByVal newValue As String)
    ' Reset the internal Collection if a new file specification is given.
    If m_FileSpec <> newValue Then
        m_FileSpec = newValue
        Set m_Filenames = Nothing
    End If
End Property

Property Get Path() As String
    Path = GetPath(m_FileSpec)
End Property
Property Let Path(ByVal newValue As String)
    ' Get current file specification, and then substitute just the path.
    FileSpec = MakeFilename(newValue, GetFileName(FileSpec))
End Property

Property Get Attributes() As VbFileAttribute
    Attributes = m_Attributes
End Property
Property Let Attributes(ByVal newValue As VbFileAttribute)
    ' Reset the internal Collection only if a new value is given.
    If m_Attributes <> newValue Then
        m_Attributes = newValue
        Set m_Filenames = Nothing
    End If
End Property

' Holds the list of all the files that match FileSpec,
' plus any other file added by the client code (read-only property)
Property Get Filenames() As Collection
    ' Build the file list "on demand", and only if necessary.
    If m_Filenames Is Nothing Then ParseFileSpec
    Set Filenames = m_Filenames
End Property

' Parses a file specification and attributes and adds
' the resulting filename to the internal m_Filenames Collection
Sub ParseFileSpec(Optional FileSpec As Variant, _
    Optional Attributes As VbFileAttribute)
    Dim file As String, Path As String
    ' Provide a default for arguments.
    If IsMissing(FileSpec) Then
        ' In this case, we need a file specification.
        If Me.FileSpec = "" Then Err.Raise 1001, , "FileSpec undefined"
        FileSpec = Me.FileSpec
        Attributes = Me.Attributes
    End If

    ' Create the internal Collection if necessary.
    If m_Filenames Is Nothing Then Set m_Filenames = New Collection
    Path = GetPath(FileSpec)
    file = Dir$(FileSpec, Attributes)
    Do While Len(file)
        m_Filenames.Add MakeFilename(Path, file)
        file = Dir$
    Loop
End Sub

Sub Copy(DestPath As String)
    Dim var As Variant, file As String, dest As String
    On Error Resume Next
    For Each var In Filenames
        file = var
        dest = MakeFilename(DestPath, GetFileName(file))
        FileCopy file, dest
        If Err = 0 Then
            RaiseEvent FileCopyComplete(file, DestPath)
        Else
            Err.Clear
        End If
    Next
End Sub

' Support routines that parse a filename. They are used internally
' but are also exposed as Public for convenience.
Sub SplitFilename(ByVal CompleteName As String, Path As String, _
    file As String, Optional Extension As Variant)
    Dim i As Integer
    ' Assume there isn't any embedded path.
    Path = "": file = CompleteName
    ' Backward search for a path delimiter
    For i = Len(file) To 1 Step -1
        If Mid$(file, i, 1) = "." And Not IsMissing(Extension) Then
            ' We have found an extension, and the caller asked for it.
            Extension = Mid$(file, i + 1)
            file = Left$(file, i - 1)
        ElseIf InStr(":\", Mid$(file, i, 1)) Then
            ' Paths don't have a trailing backslash.
            Path = Left$(file, i)
            If Right$(Path, 1) = "\" Then Path = Left$(Path, i - 1)
            file = Mid$(file, i + 1)
            Exit For
        End If
    Next
End Sub

Function GetPath(ByVal CompleteFileName As String) As String
    SplitFilename CompleteFileName, GetPath, ""
End Function

Function GetFileName(ByVal CompleteFileName As String) As String
    SplitFilename CompleteFileName, "", GetFileName
End Function

Function MakeFilename(ByVal Path As String, ByVal FileName As String, _
    Optional Extension As String) As String
    Dim result As String
    If Path <> "" Then
        ' Path might include a trailing backslash.
        result = Path & IIf(Right$(Path, 1) <> "\", "\", "")
    End If
    result = result & FileName
    If Extension <> "" Then
        ' Extension might include a dot.
        result = result & IIf(Left$(Extension, 1) = ".", ".", "") _
            & Extension
    End If
    MakeFilename = result
End Function

The structure of the class should be self-evident, so I'll just explain a few minor details. When you assign a value to either the FileSpec or the Attributes property, the class resets an internal m_Filenames Collection variable. When eventually the Filenames Public property is referenced—from outside or inside the class module—the corresponding Property Get procedure checks whether the file list should be rebuilt, and if so, it invokes the ParseFileSpec method. This method could have been made Private to the class module, but keeping it Public adds some flexibility, as I'll show in the "Filtering Input Data" section, later in this chapter. At this point, everything is ready for the Copy method, which requires only the DestPath argument to learn where files are to be copied and which can raise a FileCopyComplete event back in the client code. All the other functions—SplitFilename, GetPath, GetFilename, and so on—are support routines used for parsing filenames and paths. They're also exposed as Public methods, however, because they can be useful to the client code as well.

The client form module

Add a form module to your project, and add a few controls, as depicted in Figure 7-2:

Click to view at full size.

Figure 7-2. The preliminary version of the CFileOp sample application at design time.

Use the following code to help you decide what names to use for your controls. (Or you can just load the demonstration program from the companion CD). I've used self-explanatory names for controls, so you shouldn't have problems understanding the function of each one of them. This is the code in the form module:

' The client Form1 module
Dim WithEvents Fop As CFileOp

Private Sub Form_Load()
    ' WithEvents objects can't be auto-instancing.
    Set Fop = New CFileOp
End Sub

Private Sub cmdParse_Click()
    Dim file As Variant
    InitFOP
    lstFiles.Clear
    For Each file In Fop.Filenames
        lstFiles.AddItem file
    Next
    picStatus.Cls
    picStatus.Print "Found " & Fop.Filenames.count & " files.";
End Sub

Private Sub cmdCopy_Click()
    InitFOP
    Fop.Copy txtDestPath.Text
End Sub

' A useful routine shared by many procedures in the form
Private Sub InitFOP()
    Fop.FileSpec = txtFilespec
    Fop.Attributes = IIf(chkHidden, vbHidden, 0) + _
        IIf(chkSystem, vbSystem, 0)
End Sub

' Trapping events from CFileOp class
Private Sub Fop_FileCopyComplete(File As String, DestPath As String)
    picStatus.Cls
    picStatus.Print "Copied file " & File & " ==> " & DestPath;
End Sub

To get a taste of how events actually work, there's nothing that beats a trace session. Set some breakpoints, type some reasonable paths for the source and destination, click on the Parse or Copy button (be careful not to overwrite the files you need!), and press F8 to see the code come alive before your eyes.

Improving the Sample Application

In its simplicity, the CFileOp class module is a good piece of code that can be extensively improved with the addition of many new features. What's more important from our standpoint is that most of these additions demonstrate compelling new techniques you can implement with events.

Filtering input data

In its first version, the CFileOp class simply parses the value assigned to the FileSpec property and builds the list of the files to be copied, taking into account the value of the Attributes property. Unfortunately, the client code has no way to filter out particular files, for example, temporary or backup files or files with specific names. Thanks to the flexibility offered by events, however, you can add this capability in just a matter of seconds. You only have to add a new event declaration to the class:

' In the declaration section of the CFileOp class module
Event Parsing(file As String, Cancel As Boolean)

and add a few lines (shown here in boldface) inside the ParseFileSpec routine:

        ' ... inside the ParseFileSpec routine
        Dim Cancel As Boolean
        Do While Len(file)
            Cancel = False
            RaiseEvent Parsing(file, Cancel)
            If Not Cancel Then 
                m_Filenames.Add MakeFilename(Path, file)
            End If
            file = Dir$
        Loop

Taking advantage of the new event in the client code is even easier. Let's say that you want to exclude temporary files from the copy process. All you have to do is trap the Parsing event and set its Cancel parameter to True when the class is about to copy a file you aren't interested in, as this code demonstrates:

' In the client form module
Private Sub Fop_Parsing(file As String, Cancel As Boolean)
    Dim ext As String
    ' GetExtension is a handy method exposed by CFileOp.
    ext = LCase$(Fop.GetExtension(file))
    If ext = "tmp" Or ext = "$$$" Or ext = "bak" Then Cancel = True
End Sub

Prenotification events

So far, you have seen that the FileCopyComplete event is raised immediately after the copy operation because it's intended to give the client code a clue that something has occurred inside the class module. A more flexible class would envision the capability for the client to intervene even before the operation takes place. In other words, what you need is a WillCopyFile event:

Enum ActionConstants
    foContinue = 1
    foSkip
    foAbort
End Enum
Event WillCopyFile(file As String, DestPath As String, _
    Action As ActionConstants)

I could have used a standard Boolean Cancel argument, but an enumerated value adds a lot of flexibility. You raise a WillCopyFile event in the Copy method, just before doing the actual copy. Here's the revised procedure, with added or modified statements showed in boldface:

Sub Copy(DestPath As String)
    Dim var As Variant, file As String, dest As String
    Dim Action As ActionConstants
    On Error Resume Next
    For Each var In Filenames
        file = var
        dest = MakeFilename(DestPath, GetFileName(file))
        Action = foContinue
        RaiseEvent WillCopyFile(file, dest, Action)
        If Action = foAbort Then Exit Sub
        If Action = foContinue Then
            FileCopy file, dest
            If Err = 0 Then
                RaiseEvent FileCopyComplete(file, GetPath(dest))
            Else
                Err.Clear
            End If
        End If
    Next
End Sub

To take advantage of this new event, the client form module has been enriched with a Confirm CheckBox control that, if selected, gives the user control over the copy process. Thanks to the WillCopyFile event, you can implement this new feature with just a handful of statements:

Private Sub Fop_WillCopyFile(File As String, DestPath As String, _
    Action As ActionConstants)
    ' Exit if user isn't interested in file-by-file confirmation.
    If chkConfirm = vbUnchecked Then Exit Sub
    Dim ok As Integer
    ok = MsgBox("Copying file " & File & " to " & DestPath & vbCr _
        & "Click YES to proceed, NO to skip, CANCEL to abort", _
        vbYesNoCancel + vbInformation)
    Select Case ok
        Case vbYes: Action = foContinue
        Case vbNo: Action = foSkip
        Case vbCancel: Action = foAbort
    End Select
End Sub

You can use the mechanism of prenotification events to much greater effect than just as a means for allowing or preventing the completion of a given process. In fact, a significant point of these types of events is that most or all their arguments are passed by reference and can therefore be altered by the caller. This is similar to what you usually do with the KeyAscii argument passed to the KeyPress event procedure of a standard control. For example, you might decide that all BAK files should be copied to a different directory:

        ' Inside the WillCopyFile event procedure (in the client)...
        If LCase$(Fop.GetExtension(file)) = "bak" Then
            DestPath = "C:\Backup"
        End If

Notifying clients of error conditions

In most cases, the best means for a class to return an error to the client is by using the standard Err.Raise method. This allows the client to get a definitive confirmation that something went wrong and that appropriate steps must be taken. However, when a class communicates with its clients through events, you can explore a few alternatives to the Err.Raise method. For example, if the CFileOp class isn't able to copy a particular file, should the entire copy process be terminated? Needless to say, only the client code knows the answer, so the right thing to do is to ask it—by means of an event, of course:

Event Error(OpName As String, File As String, File2 As String, _
    ErrCode As Integer, ErrMessage As String, Action As ActionConstants)

You see that I've added a generic OpName argument so that the same Error event can be shared by all the methods in the class module. Adding support for this new event in the Copy method requires little effort:

' Inside the Copy method in the CFileOp class module...
FileCopy File, dest
If Err = 0 Then
    RaiseEvent FileCopyComplete(File, DestPath)
Else
    Dim ErrCode As Integer, ErrMessage As String
    ErrCode = Err.Number: ErrMessage = Err.Description
    RaiseEvent Error("Copy", File, DestPath, ErrCode, _
        ErrMessage, Action)
    ' Report the error to the client if user aborted the process.
    If Action = foAbort Then
        ' You need to cancel error handling, otherwise the Err.Raise
        ' method won't return the control to the client.
        On Error GoTo 0           
        Err.Raise ErrCode, , ErrMessage
    End If
Err.Clear
End If

The client now has the ability to trap errors and decide what to do with them. For example, an "Error 76 - Path not found" means that either the source or the destination isn't valid, so there isn't any point in continuing the operation:

Private Sub Fop_Error(OpName As String, File As String, File2 As String, _
    ErrCode As Integer, ErrMessage As String, Action As ActionConstants)
    If ErrCode = 76 Then
        MsgBox ErrMessage, vbCritical
        Action = foAbort
    End If
End Sub

This code doesn't test the OpName argument: This is an intentional omission because the same code can manage errors raised by all methods in the class. Also note that the class passes both ErrCode and ErrMessage by reference, and the client can, for example, modify them at will:

    ' Use a custom error scheme for this client.
    If OpName = "Copy" Then 
        ErrCode = ErrCode + 1000: ErrMessage = "Unable to Copy"
    ElseIf OpName = "Move" Then
        ErrCode = ErrCode + 2000: ErrMessage = "Unable to Move"
    End If
    Action = foAbort

Notifying clients of progress

The task of notifying the user about the progress of a process is among the most common uses for events. In a sense, each prenotification and postnotification event can be considered a signal that the process is active, so it could seem that a separate Progress event is superfluous. But you can offer your clients better service if you also expose an event that clients can use to inform the user about the progress of a task, for example using a progress bar that shows the percentage of the job accomplished. The trick is to raise this event only when the actual percentage changes so that you don't force the client to continuously update the user interface without any real reason to do so:

Event ProgressPercent(Percent As Integer)

After writing some classes that expose the ProgressPercent event, you realize that you can put most of the logic for this event in a generic procedure, which can be reused in all your class modules:

Private Sub CheckProgressPercent(Optional NewValue As Variant, _
    Optional MaxValue As Variant)
    Static Value As Variant, Limit As Variant
    Static LastPercent As Integer
    Dim CurrValue As Variant, CurrPercent As Integer
    If Not IsMissing(MaxValue) Then
        Limit = MaxValue
        If IsMissing(NewValue) Then Err.Raise 9998, , _
            "NewValue can't be omitted in the first call"
        Value = NewValue
    Else
        If IsEmpty(Limit) Then Err.Raise 9999, , "Not initialized!"
        Value = Value + IIf(IsMissing(NewValue), 1, NewValue)
    End If
    CurrPercent = (Value * 100) \ Limit
    If CurrPercent <> LastPercent Or Not IsMissing(MaxValue) Then
        LastPercent = CurrPercent
        RaiseEvent ProgressPercent(CurrPercent)
    End If
End Sub

The structure of the CheckProgressPercent routine is somewhat contorted because it has to account for many possible default values of its arguments. You can call it with two, one, or no arguments. You call it with two arguments when you want to reset its internal counters Value and Limit. You call it with just one argument when you simply want to increment Value. Finally, you call it with no arguments when you increment Value by 1 (a case so common that it deserves a courtesy treatment). This flexible scheme simplifies how the routine is invoked by the methods in the class, and in most cases you just need two statements to fire the Progress event at the right time:

' In the Copy method 
On Error Resume Next
CheckProgressPercent 0, Filenames.Count     ' Reset internal counters.
For Each var In Filenames
    CheckProgressPercent                    ' Increment by 1.
    File = var
    ...

The CheckProgressPercent routine is optimized and raises a ProgressPercent event only when the percentage actually changes. This allows you to write code in the client without worrying about tracing the changes yourself:

Private Sub Fop_ProgressPercent(Percent As Integer)
    ShowProgress picStatus, Percent
End Sub

' A reusable routine that prints to a generic PictureBox
Private Sub ShowProgress(pic As PictureBox, Percent As Integer, _
    Optional Color As Long = vbBlue)
    pic.Cls
    pic.Line (0, 0)-(pic.ScaleWidth * Percent / 100, _
        pic.ScaleHeight), Color, BF
    pic.CurrentX = (pic.ScaleWidth - pic.TextWidth(CStr(Percent) _
        & " %")) / 2
    pic.CurrentY = (pic.ScaleHeight - pic.TextHeight("%")) / 2
    pic.Print CStr(Percent) & " %";
End Sub

The CFileOp class that you'll find on the companion CD includes many other improvements, such as the support for Move and Delete commands, and the inclusion of a Parsing event that lets the client filter out specific files during the parsing process. (See Figure 7-3.)

Click to view at full size.

Figure 7-3. This version of the CFileOp demonstration program supports multiple filespecs, wildcards, additional file commands, a progress bar with a percentage indicator, and full control of individual file operations.

Multicasting

Now that I have shown you several ways to exploit events in your own classes in hopes of piquing your interest, I admit that I've reserved the best news for the grand finale (of this section about events, at least). In fact, what I've purposefully left out is that the event mechanism on which WithEvents is based is compatible with COM and with all the events raised by Visual Basic's own forms and controls.

This mechanism is also known as event multicasting. This term means that an object can raise events in all the client modules containing a WithEvents variable that points to that object. This might seem to be a negligible detail until you see how far-reaching its consequences are.

As you all know, a form module is always able to trap events from its own controls. Before multicasting, trapping controls' events in the parent form's module was the best thing a programmer could do. Well, it probably is still the best thing that you can do with events, but surely it isn't the only one. In fact, you can declare an explicit object variable, let it point to a particular control, and use it to trap that control's events. The multicasting mechanism ensures that the variable receives the event notification wherever it is declared! This means that you can move the variable to another module in the program (or to another form, or class, or actually anything but a standard BAS module) and still react to all the events raised by the control.

A class for validating TextBox controls

Let's see what this means to us, mere Visual Basic programmers. To show multicasting in action, you just need a very simple CTextBxN class module, whose only purpose is to reject any nondigit keys from a TextBox control:

Public WithEvents TextBox As TextBox

Private Sub TextBox_KeyPress(KeyAscii As Integer)
    Select Case KeyAscii
        Case 0 To 31                   ' Accept control chars.
        Case 48 To 57                  ' Accept digits.
        Case Else
            KeyAscii = 0               ' Reject anything else.
    End Select
End Sub

To test drive this class, create a form, place a TextBox control on it, and add this code:

Dim Amount As CTextBxN
Private Sub Form_Load()
    Set Amount = New CTextBxN
    Set Amount.TextBox = Text1
End Sub

Run the program, and try to type a nondigit key in Text1. After a few attempts, you'll realize that the CTextBxN class is trapping all the KeyPress events raised from Text1 and processing the validation code on behalf of the Form1 module. Seems interesting, eh? The real power of this technique becomes apparent when you have other numerical fields on your form, for example, a new Text2 control that holds a percentage value:

Dim Amount As CTextBxN, Percentage As CTextBxN
Private Sub Form_Load()
    Set Amount = New CTextBxN
    Set Amount.TextBox = Text1
    Set Percentage = New CTextBxN
    Set Percentage.TextBox = Text2
End Sub

Instead of creating distinct event procedures in the parent form module, each one validating the keys going to a distinct TextBox control, you've encapsulated the validation logic in the CTextBxN class once, and you're now reusing it over and over again. And you can do it for all the fields in Form1, as well as for any number of fields in any form of your application (not to mention all the future applications that you'll write from now on). This is reusable code!

Improving the CTextBxN class

The benefits of multicasting shouldn't make you forget that CTextBxN is a regular class module, which can be improved with properties and methods. Just as an example, let's add three new properties that make the class more useful: IsDecimal is a Boolean property that, if True, allows decimal values; FormatMask is a string used to format the number when the focus leaves the control; and SelectOnEntry is a Boolean property that states whether the current value should be highlighted when the control gets the focus. Here's the new version of the class:

Public WithEvents TextBox As TextBox
Public IsDecimal As Boolean
Public FormatMask As String
Public SelectOnEntry As Boolean

Private Sub TextBox_KeyPress(KeyAscii As Integer)
    Select Case KeyAscii
        Case 0 To 31                   ' Accept control chars.
        Case 48 To 57                  ' Accept digits.
        Case Asc(Format$(0.1, "."))    ' Accept the Decimal separator.
            If Not IsDecimal Then KeyAscii = 0
        Case Else
            KeyAscii = 0               ' Reject anything else.
    End Select
End Sub

Private Sub TextBox_GotFocus()
    TextBox.Text = FilterNumber(TextBox.Text, True)
    If SelectOnEntry Then 
        TextBox.SelStart = 0
        TextBox.SelLength = Len(TextBox.Text)
    End If
End Sub

Private Sub TextBox_LostFocus()
    If Len(FormatMask) Then 
        TextBox.Text = Format$(TextBox.Text, FormatMask)
    End If
End Sub
' Code for FilterNumber is omitted. (See Chapter 3.)

Using the new properties is a pleasure. Just set them in the Form_Load procedure and then enjoy your smarter TextBox controls:

' In the Form_Load event procedure
Amount.FormatMask = "#,###,###"
Amount.SelectOnEntry = True
Percentage.FormatMask = "0.00"
Percentage.IsDecimal = True
Percentage.SelectOnEntry = True

Send custom events to the container

Because CTextBxN is a regular class module, it can even declare and raise its own custom events. This ability is really interesting: The class "steals" controls' events from the original form but then sends the form other events. This permits a degree of sophistication that couldn't be possible otherwise. To demonstrate this concept in action, I'll show you how to add to the class full support for validation against Min and Max properties. In a regular program, validation is performed in the Validate event on the parent form. (See Chapter 3.) But now you can trap that event and preprocess it against your new custom properties:

' In the CTextsBxN class module
Event ValidateError(Cancel As Boolean)
Public Min As Variant, Max As Variant

Private Sub TextBox_Validate(Cancel As Boolean)
    If Not IsEmpty(Min) Then
        If CDbl(TextBox.Text) < Min Then RaiseEvent ValidateError(Cancel)
    End If
    If Not IsEmpty(Max) Then
        If CDbl(TextBox.Text) > Max Then RaiseEvent ValidateError(Cancel)
    End If
End Sub

If the class detects a potential out-of-range error, it just raises a ValidationError in the original form, passing the Cancel argument by reference. In the client form module, you can therefore decide whether you actually want to abort the shift focus, exactly as you would do under normal circumstances:

' Now Percentage must be declared using WithEvents.
Dim WithEvents Percentage As CTextBxN
Private Sub Form_Load()
    ' ...
    Percentage.Min = 0
    Percentage.Max = 100
End Sub
' ...
Private Sub Percentage_ValidateError(Cancel As Boolean)
    MsgBox "Invalid Percentage Value", vbExclamation
    Cancel = True
End Sub

Alternatively, you could set Cancel to True in the class module and give the client code an opportunity to reset it to False. These are just details. The important point is that you're now in complete control of what happens inside the control, and you're doing that with a minimum amount of code on the form itself because most of the logic is encapsulated in the class module.

Trapping events from multiple controls

Now that you know how you can have a class module trap events from a control, you can extend the technique to multiple controls as well. For example, you can trap events from a TextBox control and a tiny ScrollBar control beside it to simulate those fancy spin buttons that are so trendy in many Windows applications. Or you can rework the scrollable form example in Chapter 3 and build a CScrollForm class module that traps events from a form and its two companion scroll bars. Instead of rehashing such simple tasks, I prefer to focus on something new and more interesting. In the following example, I'll show you how easily you can create calculated fields using multicasting. This example is a bit more complex, but I'm sure that in the end you'll be glad to have spent some time on it.

The CTextBoxCalc class module I built is able to trap the Change event from up to five distinct TextBox controls (the independent fields) and use this capability to update the contents of another Textbox on the form (the dependent field) without any intervention from the main program. To create a generic calculated field, I needed to devise a way for the client code to specify the expression that must be reevaluated each time one of the independent controls raises a Change event. To this end, the class exposes a SetExpression method that accepts an array of parameters. Each parameter can be a reference to a control, a number, or a string that represents one of the four math operators. Look, for example, at the following code:

' Example of client code that uses the CTextBoxCalc class
' txtTax and txtGrandTotal depend on txtAmount and txtPercent.
Dim Tax As New CTextBoxCalc, GrandTotal As New CTextBoxCalc
' Link the class to the control on which the result is to be displayed.
Set Tax.TextBox = txtTax
' Set the expression "Amount * Percent / 100".
Tax.SetExpression txtAmount, "*", txtPercent, "/", 100
' Create a GrandTotal calculated field, equal to "Amount + Tax".
Set GrandTotal.TextBox = txtGrandTotal
GrandTotal.SetExpression txtAmount, "+", txtTax

The intricacy of the CTextBoxCalc class derives mostly from the need to parse the arguments passed to the SetExpression method. I kept this intricacy to a minimum and renounced sophisticated features such as allowing different priorities among operators, bracketed subexpressions, and functions. This leaves the four math operators, which are evaluated in a strict left-to-right order. (For example, "2+3*4" evaluates to 20 instead of 14.) On the other hand, the complete class module has just 80 lines of code:

' The complete source code for CTextBoxCalc class
Public TextBox As TextBox
Public FormatMask As String
' We can trap events from max 5 TextBox controls.
Private WithEvents Text1 As TextBox
Private WithEvents Text2 As TextBox
Private WithEvents Text3 As TextBox
Private WithEvents Text4 As TextBox
Private WithEvents Text5 As TextBox
' Here we store the arguments passed to SetExpression.
Dim expression() As Variant

Sub SetExpression(ParamArray args() As Variant)
    Dim i As Integer, n As Integer
    ReDim expression(LBound(args) To UBound(args)) As Variant
    For i = LBound(args) To UBound(args)
        If IsObject(args(i)) Then
            ' Objects must be stored as such, using Set.
            Set expression(i) = args(i)
            If TypeName(args(i)) = "TextBox" Then
                n = n + 1
                If n = 1 Then Set Text1 = args(i)
                If n = 2 Then Set Text2 = args(i)
                If n = 3 Then Set Text3 = args(i)
                If n = 4 Then Set Text4 = args(i)
                If n = 5 Then Set Text5 = args(i)
            End If
        Else
            ' Store number and strings without the Set keyword.
            expression(i) = args(i)
        End If
    Next
End Sub

' Here we actually evaluate the result.
Sub EvalExpression()
    Dim i As Integer, opcode As Variant
    Dim value As Variant, operand As Variant
    On Error GoTo Error_Handler
    For i = LBound(expression) To UBound(expression)
        If Not IsObject(expression(i)) And VarType(expression(i)) _
            = vbString Then
                opcode = expression(i)
        Else
            ' This works with numbers and Text (default) properties alike.
            operand = CDbl(expression(i))
            Select Case opcode
                Case Empty: value = operand
                Case "+": value = value + operand
                Case "-": value = value - operand
                Case "*": value = value * operand
                Case "/": value = value / operand
            End Select
            opcode = Empty
        End If
    Next
    If Len(FormatMask) Then value = Format$(value, FormatMask)
    TextBox.Text = value
    Exit Sub
Error_Handler:
    TextBox.Text = ""
End Sub

' Here we trap events from the independent fields.
Private Sub Text1_Change()
    EvalExpression
End Sub
' ... Text2-Text5 Change procedures .... (omitted)

The class can trap events from a maximum of five TextBox independent controls, but the expression could refer to just one or two of them. This is OK: If a WithEvents variable isn't assigned and remains Nothing, it simply stays inert and never raises events in the class. It isn't useful but doesn't do any harm either.

To get an idea of the potential of this class, run the demonstration program on the companion CD and see how you can grow a spreadsheet-like form that accepts data in a couple of fields and automatically updates the other two fields. (See Figure 7-4 for an example of how that might work.) The same application demonstrates both the CTextBxN and the CTextBoxCalc classes.

Figure 7-4. You can create smart forms that contain live calculated fields by using reusable external class modules exclusively.

The pitfalls of multicasting

Exploiting the event multicasting features in your application is among the best favors you can do yourself. Before you get too carried away, though, you should be aware that there are a few problems with this technique.